14.1 REST简介

14.1.1 资源就是一切

资源是REST架构方式的核心概念。在博客程序中,用户、文章、评论等都是资源。

每个资源都要使用唯一的URL表示。如某一篇文章URL:/api/posts/1234

某一类资源的集合也要有个URL。如文章集合URL:api/posts/

API还可以为某一类资源的逻辑子集合定义集合URL。如某一篇文章的所有评论URL:/api/posts/1234/comments/

注意
请求的URL末端没有/,路由没有/,则不重定向;
请求的URL末端没有/,路由有/,则重定向转向末端带/的URL。

14.1.2 请求方法

在资源URL上发送请求,使用请求方法表示期望执行的操作。

表14-1 REST架构API中使用的HTTP请求方法

请求方法 目标 说明 状态码
GET 单个资源的URL 获取目标资源 200
GET 资源集合的URL 获取资源的集合(如果服务器实现了分页,就是一页中的资源) 200
POST 资源集合的URL 创建新资源,并将其加入目标集合。服务器为新资源指派URL, 并在响应的Location首部中返回 201
PUT 单个资源的URL 修改一个现有资源。如果客户端能为资源指派URL,还可用来创建新资源 200
DELETE 单个资源的URL 删除一个资源 200
DELETE 资源集合的URL 删除目标集合中的所有资源 200

14.1.3 请求和响应主体

请求和响应中Content-Type首部用于指明主体中资源的编码方式。常用的编码方式是JavaScript对象表示法(JSON)和可拓展标记语言(XML)。

14.1.4 版本

Web服务的容错能力要比一般的Web程序大,而且还要保证旧版客户端能继续使用(因为有些客服端如手机客户端,没有进行升级,但也要保证其能正常使用)。处理方法是使用版本区分Web服务所处理的URL。例如首次发布的博客Web服务可以通过/api/v1.0/posts/提供文章集合。

14.2 使用Flask提供REST Web服务

使用Flask创建REST Web服务很简单,使用route()修饰器及其methods可选参数即可。处理JSON数据也同样简单,通过request.json这个字典获取即可。返回包含JSON的响应只用使用Flask提供的jsonify()辅助函数从Python字典中生成JSON即可。

14.2.1 创建API蓝本

API蓝本结构如下:

1
2
3
4
5
6
7
8
9
10
|-flasky
|-app/
|-api_1_0 # 可选
|-__init__.py
|-users.py
|-posts.py
|-comments.py
|-authentication.py
|-errors.py
|-decorators.py

1. 在app/api/__init__.py中构造API蓝本:

1
2
3
4
5
from flask import Blueprint
api = Blueprint('api', __name__)
from app.api import authentication, posts, users, comments, errors

2. 在app/__init__.py中注册API蓝本:

1
2
3
4
5
6
7
# ...
def create_app(config_name):
# ...
from app.api import api as api_blueprint
app.register_blueprint(api_blueprint, url_prefix='/api/v1.0')
# ...

14.2.5 资源和JSON的序列化转换

3. 在app/models.py中定义把文章、用户转换成JSON格式的序列化字典的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Post(db.Model):
# ...
def to_json(self):
json_psot = {
'url': url_for('api.get_post', id=self.id),
'body': self.body,
'body_html': self.body_html,
'timestamp': self.timestamp,
'author_url': url_for('api.get_user', id=self.author_id),
'comments_url': url_for('api.get_post_commnets', id=self.id),
'comment_count': self.comments.count()
}
return json_psot
class User(UserMixin, db.Model):
# ...
def to_json(self):
json_user = {
'url': url_for('api.get_user', id=self.id),
'username': self.username,
'member_since': self.member_since,
'last_seen': self.last_seen,
'posts_url': url_for('api.get_user_posts', id=self.id),
'followed_posts_url': url_for('api.get_user_followed_posts', id=self.id),
'post_count': self.posts.count()
}
return json_user

4. 在app/models.py中定义从JSON格式数据创建博客文章的方法:

1
2
3
4
5
6
7
8
9
10
11
from app.api.exceptions import ValidationError
# ...
class Post(db.Model):
# ...
@staticmethod
def from_json(json_post):
body = json_post.get('body')
if body is None or body == '':
raise ValidationError('post does not have a body') # 抛出异常
return Post(body=body)

5. 在app/exceptions.py中定义ValidationError类:

1
2
class ValidationError(ValueError):
pass

为了避免在视图函数中编写捕获异常的代码,我们可创建一个全局异常处理程序:

6. 在app/api/errors.py中定义API中ValidationError异常的处理程序:

1
2
3
4
# 定义API中ValidationError错误处理程序
@api.errorhandler(ValidationError)
def validation_error(e):
return bad_request(e.args[0])
  • bad_request方法在第11步中定义。

14.2.6 实现资源端点 & 14.2.7 分页大型资源集合

GET请求往往是最简单的,因为它们只返回信息,无需修改信息。

7. 在app/api/posts.py中定义分页博客文章资源的GET请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from flask import jsonify, request, g, url_for, current_app
from app import db
from app.models import Post, Permission
from app.api import api
from app.api.decorators import permission_required
from app.api.errors import forbidden
# 获取分页文章集合
@api.route('/posts/')
def get_posts():
page = request.args.get('page', 1, type=int)
pagination = Post.query.paginate(page,
per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
posts = pagination.items
prev = None
if pagination.has_prev:
prev = url_for('api.get_posts', page=page-1, _external=True)
next = None
if pagination.has_next:
next = url_for('api.get_posts', page=page+1, _external=True)
return jsonify({
'posts': [post.to_json() for post in posts],
'perv': prev,
'next': next,
'count': pagination.total
})
# 获取某一篇文章
@api.route('/posts/<int:id>')
def get_post(id):
post = Post.query.get_or_404(id)
return jsonify(post.to_json())

8. 在app/api/posts.py中定义博客文章资源的POST请求:

1
2
3
4
5
6
7
8
9
10
# ...
@api.route('/posts/', methods=['POST'])
@permission_required(Permission.WRITE_ARTICLES)
def new_post():
post = Post.from_json(request.json)
post.author = g.current_user
db.session.add(post)
db.session.commit()
return jsonify(post.to_json()), 201, {'Location': url_for('api.get_post', id=post.id)}

9. 在app/api/posts.py中定义博客文章资源的PUT请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
# ...
@api.route('/posts/<int:id>', methods=['PUT'])
@permission_required(Permission.WRITE_ARTICLES)
def edit_post(id):
post = Post.query.get_or_404(id)
if g.current_user != post.author and \
not g.current_user.can(Permission.ADMINISTER):
return forbidden('Insufficient permissions')
post.body = request.json.get('body', post.body)
db.session.add(post)
db.session.commit()
return jsonify(post.to_json())

从第6-8步中用到了permission_required修饰器,下面看看permission_required修饰器如何定义:

10. 在app/api/decoratiors.py中定义permission_required修饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from functools import wraps
from flask import g
from app.api.errors import forbidden
def permission_required(permission):
def decorator(func):
@wraps(func)
def decorated_function(*args, **kwargs):
if not g.current_user.can(permission):
return forbidden('Insufficient permissions')
return func(*args, **kwargs)
return decorated_function
return decorator

从第6-9步中都用到了app.api.errors.forbidden方法,下面看看app/api/errors.py文件如何定义:

11. 在app/api/errors.py中定义400、401、403状态码的错误处理程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from flask import jsonify
from app.exceptions import ValidationError
from app.api import api
# 无效请求
def bad_request(message):
response = jsonify({'error': 'bad request', 'message': message})
response.status_code = 400
return response
# 未登录
def unauthorized(message):
response = jsonify({'error': 'unauthorizde', 'message': message})
response.status_code = 401
return response
# 禁止访问
def forbidden(message):
response = jsonify({'error': 'forbidden', 'message': message})
response.status_code = 403
return response

14.2.2 错误处理

为统一错误处理程序的响应格式,需要修改app/main/errors.py,使其内容协商

为所有客户端生成适当响应的一种方法是,在错误处理程序中,根据客户端请求的格式改写响应,这种技术称为内容协商

12. 在app/main/errors.py中使用HTTP内容协商处理错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from flask import render_template, request, jsonify
from . import main
@main.app_errorhandler(403)
def forbidden(e):
# 判断请求的首部Accept字段(Werkzeug将其解码为requset.accept_mimetypes)接受哪种响应格式(json或xml)
if request.accept_mimetypes.accept_json and \
not request.accept_mimetypes.accept_html:
response = jsonify({'error': 'forbidden'})
response.status_code = 403
return response
return render_template('403.html'), 403
@main.app_errorhandler(404)
def page_not_found(e):
if request.accept_mimetypes.accept_json and \
not request.accept_mimetypes.accept_html:
response = jsonify({'error': 'not found'})
response.status_code = 404
return response
return render_template('404.html'), 404
@main.app_errorhandler(500)
def internal_server_error(e):
if request.accept_mimetypes.accept_json and \
not request.accept_mimetypes.accept_html:
response = jsonify({'error': 'internal server error'})
response.status_code = 500
return response
return render_template('500.html'), 500
  • 该例的错误处理程序会检查Accept请求首部(Werkzeug将其解码为requset.accept_mimetypes),根据首部的值决定客户端期望接受的响应格式(JSON或XML)。浏览器一般不限制响应的格式,所以只为只接受JSON而不接受HTML格式的客户端生成JSON格式响应。

表14-2 API返回的常见HTTP状态码

状态码 名称 说明
200 OK(成功) 请求成功完成
201 Created(已创建) 请求成功完成并创建了一个新资源
400 Bad request(坏请求) 请求不可用或不一致
401 Unauthorized(未授权) 请求未包含认证信息
403 Forbidden(禁止) 请求中发送的认证密令无权访问目标
404 Notfound(未找到) URL对应的资源不存在
405 Method not allowed(不允许使用的方法 指定资源不支持请求使用的方法
500 Internal server error(内部服务器错误) 处理请求的过程中发生意外错误

14.2.3 使用Flask-HTTPAuth认证用户 & 14.2.4 基于令牌的认证

和普通Web程序一样,Web服务也需要保护信息,确认未经授权的用户无法访问。为此,RIA必须询问用户的登录密令,并将其传给服务器验证。

REST Web服务的特征之一就是无状态,即服务器在两次请求之间不能“记住”客户端的任何信息,客户端发出的请求必须包含所有信息,因此所有请求都必须包含用户密令。

默认情况下,Flask会把会话保存在客户端的cookie中,因此服务器没有保存任何用户相关信息,都转交给客户端保存了。这种实现方式看起来遵守REST架构的无状态要求,但在REST Web服务中使用cookie有点不现实,因为Web浏览器之外的客户端很难提供对cookie的支持。

又因为REST架构基于HTTP协议,所以发送密令的最佳方式是使用HTTP认证,基本认证和摘要认证都可以。在HTTP认证中,用户密令包含在请求的Authorization首部中。

每次请求时,客户端都要发送认证密令,为了避免总是发送敏感信息(认证密令),我们可以提供一种基于令牌的认证方案:客户端(1)先把登录密令发送给一个特殊的URL,从而生成认证令牌;(2)客户端获得令牌后,就可以用令牌代替密令认证请求。

13. 在app/api/authentication.py中初始化Flask-HTTPAuth,并支持令牌验证回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from flask import g, jsonify
from flask_httpauth import HTTPBasicAuth
from app.models import User
from app.api import api
from app.api.errors import unauthorized, forbidden
auth = HTTPBasicAuth()
@auth.verify_password
def verify_password(email_or_token, password):
# (1)
if email_or_token == '':
return False
# (2)
if password == '':
g.current_user = User.verify_auth_token(email_or_token)
g.token_used = True
return g.current_user is not None
# (3)
user = User.query.filter_by(email=email_or_token).first()
if not user:
return False
g.current_user = user
g.token_used = False
return user.verify_password(password) # 调用User模型中的verify_password方法
  • Flask-HTTPAuth提供了一个便利的包(HTTPBasicAuth().verify_password),可以把协议的细节隐藏在修饰器中,类似于Flask-Login提供的login_required修饰器。
  • 由于这种用户认证方法只在API蓝本中使用,所以Flask-HTTPAuth只在蓝本包中初始化,而不像其他扩展那样在程序包中初始化。
  • 验证回调函数把通过认证的用户保存在Flask的全局对象g中,如此一来,视图函数就能进行访问。
  • 该例中,第一个参数是电子邮件或认证令牌。(1)如果这个参数为空,则返回False;(2)如果password参数为空,就假定email_or_token参数是认证令牌,按照令牌的方式进行认证;(3)如果两个参数都不为空,就假定使用了电子邮件和密码进行认证。
  • 为了让视图函数能区分这两种认证方法(电子邮件+密码、认证令牌),我们添加了g.token_used变量。

14. 在app/api/authentication.py中定义生成认证令牌的路由:

1
2
3
4
5
6
7
8
9
# ...
# 定义用于获取密令的路由
@api.route('/tokens/', methods=['POST'])
def get_token():
if g.current_user.is_anonymous or g.token_used:
return unauthorized('Invalid credentials')
return jsonify({'token': g.current_user.generate_auth_token(expiration=3600),
'expiration': 3600})

15. 在app/api/authentication.py中定义Flask-HTTPAuth错误处理程序:

1
2
3
4
5
# ...
@auth.error_handler
def auth_error():
return unauthorized('Invalid credentials')

16. 在app/api/authentication.py中使得API蓝本所有路由在每次请求前都进行认证:

1
2
3
4
5
6
7
# ...
@api.before_request
@auth.login_required
def before_request():
if not g.current_user.is_anonymous and \
not g.current_user.confirmed: # 判断该用户是否已经确认账户
return forbidden('Unconfirmed account')

在第13步中使用到了User.verify_auth_token方法和第14步中使用到了User.generate_auth_token方法,其定义如下:

17. 在app/models.py中定义基于令牌的认证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class User(UserMixin, db.Model):
# ...
def generate_auth_token(self, exporation):
s = Serializer(current_app.confir['SECRET_KEY'], expires_in=exporation)
return s.dumps({'id': self.id}).decode('utf-8')
@staticmethod
def verify_auth_token(token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token)
except:
return None
return User.query.get(data['id'])

14.2.8 使用HTTPie测试Web服务

1. 安装HTTPie:

1
(venv) $ pip install httpie

2. GET请求可按照如下方式发起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(venv) $ http --json --auth <email>:<password> GET http://127.0.0.1:5000/api/v1.0/posts
HTTP/1.0 200 OK
Content-Length: 7018
Content-Type: application/json
Date: Sun, 22 Dec 2013 08:11:24 GMT
Server: Werkzeug/0.9.4 python/3.6
{
"posts": [
...
],
"prev": null,
"next": "http://127.0.0.1:5000/api/v1.0/posts/?page=2",
"count": 150
}

如果API允许匿名用户访问的话,可以这个发起请求:

1
(venv) $ http --json --auth : GET http://127.0.0.1:5000/api/v1.0/posts/

3. 发送POST请求创建一篇新博客文章:

1
2
3
4
(venv) $ http --auth <email>:<password> --json POST http://127.0.0.1:5000/api/v1.0/posts/ "body=I'm adding a post from the *command line*."
HTTP/1.0 201 CREATED
Content-Length :360
# ...

4. 要想使用认证令牌,可向/api/v1.0/token发送请求:

1
2
3
4
5
6
7
8
9
10
11
(venv) $ http --auth <email>:<password> --json GET http://127.0.0.1:5000/api/v1.0/token
HTTP/1.0 200 OK
Content-Lenght: 162
Content-Type: application/json
Date: Sat, 04 Jan 2017 08:38:47 GMT
Server: Werkzeug/0.9.4 Python/3.3.3
{
"expiration": 3600,
"token": "eqJpYXQiOjex..............iSFMy..."
}

5. 在接下来的1小时内,可以用这个令牌访问API,请求时要和空密码一起发送:

1
(venv) $ http --json --auth eyJpYXQi.....ISFMy...: GET http://127.0.0.1:5000/api/v1.0/posts/

令牌过期后,请求会返回401错误,表示需要重新获取令牌。